Domina los combinadores de Promesas de JavaScript (Promise.all, Promise.allSettled, Promise.race, Promise.any) para una programación asíncrona eficiente y robusta en aplicaciones globales.
Combinadores de Promesas en JavaScript: Patrones Asíncronos Avanzados para Aplicaciones Globales
La programación asíncrona es una piedra angular del JavaScript moderno, especialmente al construir aplicaciones web que interactúan con APIs, bases de datos o realizan operaciones que consumen mucho tiempo. Las Promesas de JavaScript proporcionan una potente abstracción para gestionar operaciones asíncronas, pero dominarlas requiere comprender patrones avanzados. Este artículo profundiza en los combinadores de Promesas de JavaScript – Promise.all, Promise.allSettled, Promise.race y Promise.any – y cómo se pueden utilizar para crear flujos de trabajo asíncronos eficientes y robustos, particularmente en el contexto de aplicaciones globales con condiciones de red y fuentes de datos variables.
Entendiendo las Promesas: Un Rápido Resumen
Antes de sumergirnos en los combinadores, repasemos rápidamente las Promesas. Una Promesa representa el resultado final de una operación asíncrona. Puede estar en uno de tres estados:
- Pendiente (Pending): El estado inicial, ni cumplida ni rechazada.
- Cumplida (Fulfilled): La operación se completó con éxito, con un valor resultante.
- Rechazada (Rejected): La operación falló, con una razón (generalmente un objeto Error).
Las Promesas ofrecen una forma más limpia y manejable de gestionar operaciones asíncronas en comparación con los callbacks tradicionales. Mejoran la legibilidad del código y simplifican el manejo de errores. Crucialmente, también forman la base de los combinadores de Promesas que exploraremos.
Combinadores de Promesas: Orquestando Operaciones Asíncronas
Los combinadores de Promesas son métodos estáticos en el objeto Promise que te permiten gestionar y coordinar múltiples Promesas. Proporcionan herramientas potentes para construir flujos de trabajo asíncronos complejos. Examinemos cada uno en detalle.
Promise.all(): Ejecutando Promesas en Paralelo y Agregando Resultados
Promise.all() toma un iterable (generalmente un array) de Promesas como entrada y devuelve una única Promesa. Esta Promesa devuelta se cumple cuando todas las Promesas de entrada se han cumplido. Si alguna de las Promesas de entrada se rechaza, la Promesa devuelta se rechaza inmediatamente con la razón de la primera Promesa rechazada.
Caso de Uso: Cuando necesitas obtener datos de múltiples APIs simultáneamente y procesar los resultados combinados, Promise.all() es ideal. Por ejemplo, imagina construir un panel de control que muestra información meteorológica de diferentes ciudades de todo el mundo. Los datos de cada ciudad podrían obtenerse mediante una llamada a API separada.
async function fetchWeatherData(city) {
try {
const response = await fetch(`https://api.example.com/weather?city=${city}`); // Reemplazar con un endpoint de API real
if (!response.ok) {
throw new Error(`Fallo al obtener los datos del clima para ${city}`);
}
return await response.json();
} catch (error) {
console.error(`Error al obtener los datos del clima para ${city}: ${error}`);
throw error; // Relanzar el error para que sea capturado por Promise.all
}
}
async function displayWeatherData() {
const cities = ['Londres', 'Tokio', 'Nueva York', 'Sídney'];
try {
const weatherDataPromises = cities.map(city => fetchWeatherData(city));
const weatherData = await Promise.all(weatherDataPromises);
weatherData.forEach((data, index) => {
console.log(`Clima en ${cities[index]}:`, data);
// Actualizar la interfaz de usuario con los datos del clima
});
} catch (error) {
console.error('Fallo al obtener los datos del clima para todas las ciudades:', error);
// Mostrar un mensaje de error al usuario
}
}
displayWeatherData();
Consideraciones para Aplicaciones Globales:
- Latencia de Red: Las solicitudes a diferentes APIs en distintas ubicaciones geográficas pueden experimentar latencias variables.
Promise.all()no garantiza el orden en que las Promesas se cumplen, solo que todas se cumplan (o una se rechace) antes de que la Promesa combinada se resuelva. - Límites de Tasa de API: Si estás haciendo múltiples solicitudes a la misma API o a múltiples APIs con límites de tasa compartidos, podrías exceder esos límites. Implementa estrategias como poner en cola las solicitudes o usar un retroceso exponencial (exponential backoff) para manejar los límites de tasa de forma elegante.
- Manejo de Errores: Recuerda que si cualquier Promesa se rechaza, toda la operación
Promise.all()falla. Esto podría no ser deseable si quieres mostrar datos parciales incluso si algunas solicitudes fallan. Considera usarPromise.allSettled()en tales casos (explicado a continuación).
Promise.allSettled(): Manejando el Éxito y el Fracaso Individualmente
Promise.allSettled() es similar a Promise.all(), pero con una diferencia crucial: espera a que todas las Promesas de entrada se resuelvan (settle), independientemente de si se cumplen o se rechazan. La Promesa devuelta siempre se cumple con un array de objetos, cada uno describiendo el resultado de la Promesa de entrada correspondiente. Cada objeto tiene una propiedad status (ya sea "fulfilled" o "rejected") y una propiedad value (si se cumple) o reason (si se rechaza).
Caso de Uso: Cuando necesitas recopilar resultados de múltiples operaciones asíncronas, y es aceptable que algunas fallen sin que toda la operación falle, Promise.allSettled() es la mejor opción. Imagina un sistema que procesa pagos a través de múltiples pasarelas de pago. Es posible que desees intentar todos los pagos y registrar cuáles tuvieron éxito y cuáles fallaron.
async function processPayment(paymentGateway, amount) {
try {
const response = await paymentGateway.process(amount); // Reemplazar con una integración de pasarela de pago real
if (response.status === 'success') {
return { status: 'fulfilled', value: `Pago procesado con éxito a través de ${paymentGateway.name}` };
} else {
throw new Error(`Pago fallido a través de ${paymentGateway.name}: ${response.message}`);
}
} catch (error) {
return { status: 'rejected', reason: `Pago fallido a través de ${paymentGateway.name}: ${error.message}` };
}
}
async function processMultiplePayments(paymentGateways, amount) {
const paymentPromises = paymentGateways.map(gateway => processPayment(gateway, amount));
const results = await Promise.allSettled(paymentPromises);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(result.value);
} else {
console.error(result.reason);
}
});
// Analizar los resultados para determinar el éxito/fracaso general
const successfulPayments = results.filter(result => result.status === 'fulfilled').length;
const failedPayments = results.filter(result => result.status === 'rejected').length;
console.log(`Pagos exitosos: ${successfulPayments}`);
console.log(`Pagos fallidos: ${failedPayments}`);
}
// Pasarelas de pago de ejemplo
const paymentGateways = [
{ name: 'PayPal', process: (amount) => Promise.resolve({ status: 'success', message: 'Pago exitoso' }) },
{ name: 'Stripe', process: (amount) => Promise.reject({ status: 'error', message: 'Fondos insuficientes' }) },
{ name: 'Worldpay', process: (amount) => Promise.resolve({ status: 'success', message: 'Pago exitoso' }) },
];
processMultiplePayments(paymentGateways, 100);
Consideraciones para Aplicaciones Globales:
- Robustez:
Promise.allSettled()mejora la robustez de tus aplicaciones al asegurar que todas las operaciones asíncronas se intenten, incluso si algunas fallan. Esto es particularmente importante en sistemas distribuidos donde las fallas son comunes. - Informes Detallados: El array de resultados proporciona información detallada sobre el resultado de cada operación, lo que te permite registrar errores, reintentar operaciones fallidas o proporcionar a los usuarios comentarios específicos.
- Éxito Parcial: Puedes determinar fácilmente la tasa de éxito general y tomar las acciones apropiadas basadas en el número de operaciones exitosas y fallidas. Por ejemplo, podrías ofrecer métodos de pago alternativos si la pasarela principal falla.
Promise.race(): Eligiendo el Resultado Más Rápido
Promise.race() también toma un iterable de Promesas como entrada y devuelve una única Promesa. Sin embargo, a diferencia de Promise.all() y Promise.allSettled(), Promise.race() se resuelve tan pronto como cualquiera de las Promesas de entrada se resuelve (ya sea que se cumpla o se rechace). La Promesa devuelta se cumple o se rechaza con el valor o la razón de la primera Promesa resuelta.
Caso de Uso: Cuando necesitas seleccionar la respuesta más rápida de múltiples fuentes, Promise.race() es una buena opción. Imagina consultar múltiples servidores por los mismos datos y usar la primera respuesta que recibes. Esto puede mejorar el rendimiento y la capacidad de respuesta, especialmente en situaciones donde algunos servidores podrían estar temporalmente no disponibles o más lentos que otros.
async function fetchDataFromServer(serverURL) {
try {
const response = await fetch(serverURL, {signal: AbortSignal.timeout(5000)}); //Añadir un tiempo de espera de 5 segundos
if (!response.ok) {
throw new Error(`Fallo al obtener datos de ${serverURL}`);
}
return await response.json();
} catch (error) {
console.error(`Error al obtener datos de ${serverURL}: ${error}`);
throw error;
}
}
async function getFastestResponse() {
const serverURLs = [
'https://server1.example.com/data', // Reemplazar con URLs de servidores reales
'https://server2.example.com/data',
'https://server3.example.com/data',
];
try {
const dataPromises = serverURLs.map(serverURL => fetchDataFromServer(serverURL));
const fastestData = await Promise.race(dataPromises);
console.log('Datos más rápidos recibidos:', fastestData);
// Usar los datos más rápidos
} catch (error) {
console.error('Fallo al obtener datos de cualquier servidor:', error);
// Manejar el error
}
}
getFastestResponse();
Consideraciones para Aplicaciones Globales:
- Tiempos de Espera (Timeouts): Es crucial implementar tiempos de espera al usar
Promise.race()para evitar que la Promesa devuelta espere indefinidamente si algunas de las Promesas de entrada nunca se resuelven. El ejemplo anterior usa `AbortSignal.timeout()` para lograr esto. - Condiciones de Red: El servidor más rápido puede variar dependiendo de la ubicación geográfica del usuario y las condiciones de la red. Considera usar una Red de Entrega de Contenidos (CDN) para distribuir tu contenido y mejorar el rendimiento para los usuarios de todo el mundo.
- Manejo de Errores: Si la Promesa que 'gana' la carrera se rechaza, entonces todo el Promise.race se rechaza. Asegúrate de que cada Promesa tenga un manejo de errores apropiado para prevenir rechazos inesperados. Además, si la promesa "ganadora" se rechaza debido a un tiempo de espera (como se muestra arriba), las otras promesas continuarán ejecutándose en segundo plano. Es posible que necesites agregar lógica para cancelar esas otras promesas usando `AbortController` si ya no son necesarias.
Promise.any(): Aceptando la Primera Promesa Cumplida
Promise.any() es similar a Promise.race(), pero con un comportamiento ligeramente diferente. Espera a que la primera Promesa de entrada se cumpla. Si todas las Promesas de entrada se rechazan, Promise.any() se rechaza con un AggregateError que contiene un array de las razones del rechazo.
Caso de Uso: Cuando necesitas recuperar datos de múltiples fuentes y solo te importa el primer resultado exitoso, Promise.any() es una buena opción. Esto es útil cuando tienes fuentes de datos redundantes o APIs alternativas que proporcionan la misma información. Prioriza el éxito sobre la velocidad, ya que espera el primer cumplimiento, incluso si algunas Promesas se rechazan rápidamente.
async function fetchDataFromSource(sourceURL) {
try {
const response = await fetch(sourceURL);
if (!response.ok) {
throw new Error(`Fallo al obtener datos de ${sourceURL}`);
}
return await response.json();
} catch (error) {
console.error(`Error al obtener datos de ${sourceURL}: ${error}`);
throw error;
}
}
async function getFirstSuccessfulData() {
const dataSources = [
'https://source1.example.com/data', // Reemplazar con URLs de fuentes de datos reales
'https://source2.example.com/data',
'https://source3.example.com/data',
];
try {
const dataPromises = dataSources.map(sourceURL => fetchDataFromSource(sourceURL));
const data = await Promise.any(dataPromises);
console.log('Primeros datos exitosos recibidos:', data);
// Usar los datos exitosos
} catch (error) {
if (error instanceof AggregateError) {
console.error('Fallo al obtener datos de cualquier fuente:', error.errors);
// Manejar el error
} else {
console.error('Ocurrió un error inesperado:', error);
}
}
}
getFirstSuccessfulData();
Consideraciones para Aplicaciones Globales:
- Redundancia:
Promise.any()es particularmente útil cuando se trata de fuentes de datos redundantes que proporcionan información similar. Si una fuente no está disponible o es lenta, puedes confiar en las otras para proporcionar los datos. - Manejo de Errores: Asegúrate de manejar el
AggregateErrorque se lanza cuando todas las Promesas de entrada se rechazan. Este error contiene un array de las razones de rechazo individuales, lo que te permite depurar y diagnosticar los problemas. - Priorización: El orden en que proporcionas las Promesas a
Promise.any()importa. Coloca las fuentes de datos más confiables o rápidas primero para aumentar la probabilidad de un resultado exitoso.
Eligiendo el Combinador Correcto: Un Resumen
Aquí tienes un resumen rápido para ayudarte a elegir el combinador de Promesas apropiado para tus necesidades:
- Promise.all(): Úsalo cuando necesites que todas las Promesas se cumplan con éxito y quieras fallar inmediatamente si alguna Promesa se rechaza.
- Promise.allSettled(): Úsalo cuando quieras esperar a que todas las Promesas se resuelvan, independientemente del éxito o fracaso, y necesites información detallada sobre cada resultado.
- Promise.race(): Úsalo cuando quieras elegir el resultado más rápido de múltiples Promesas y solo te importe la primera que se resuelva.
- Promise.any(): Úsalo cuando quieras aceptar el primer resultado exitoso de múltiples Promesas y no te importe si algunas Promesas se rechazan.
Patrones Avanzados y Mejores Prácticas
Más allá del uso básico de los combinadores de Promesas, hay varios patrones avanzados y mejores prácticas a tener en cuenta:
Limitando la Concurrencia
Cuando se trata de un gran número de Promesas, ejecutarlas todas en paralelo podría sobrecargar tu sistema o exceder los límites de tasa de la API. Puedes limitar la concurrencia usando técnicas como:
- División en Lotes (Chunking): Divide las Promesas en lotes más pequeños y procesa cada lote secuencialmente.
- Uso de un Semáforo: Implementa un semáforo para controlar el número de operaciones concurrentes.
Aquí hay un ejemplo usando la división en lotes:
async function processInChunks(promises, chunkSize) {
const results = [];
for (let i = 0; i < promises.length; i += chunkSize) {
const chunk = promises.slice(i, i + chunkSize);
const chunkResults = await Promise.all(chunk);
results.push(...chunkResults);
}
return results;
}
// Ejemplo de uso
const myPromises = [...Array(100)].map((_, i) => Promise.resolve(i)); //Crear 100 promesas
processInChunks(myPromises, 10) // Procesar 10 promesas a la vez
.then(results => console.log('Todas las promesas resueltas:', results));
Manejando Errores con Elegancia
Un manejo de errores adecuado es crucial cuando se trabaja con Promesas. Usa bloques try...catch para capturar errores que puedan ocurrir durante las operaciones asíncronas. Considera usar librerías como p-retry o retry para reintentar automáticamente las operaciones fallidas.
async function fetchDataWithRetry(url, retries = 3) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`¡Error HTTP! estado: ${response.status}`);
}
return await response.json();
} catch (error) {
if (retries > 0) {
console.log(`Reintentando en 1 segundo... (Reintentos restantes: ${retries})`);
await new Promise(resolve => setTimeout(resolve, 1000)); // Esperar 1 segundo
return fetchDataWithRetry(url, retries - 1);
} else {
console.error('Máximo de reintentos alcanzado. La operación falló.');
throw error;
}
}
}
Usando Async/Await
async y await proporcionan una forma de trabajar con Promesas que parece más síncrona. Pueden mejorar significativamente la legibilidad y mantenibilidad del código.
Recuerda usar bloques try...catch alrededor de las expresiones await para manejar posibles errores.
Cancelación
En algunos escenarios, es posible que necesites cancelar Promesas pendientes, especialmente cuando se trata de operaciones de larga duración o acciones iniciadas por el usuario. Puedes usar la API AbortController para indicar que una Promesa debe ser cancelada.
const controller = new AbortController();
const signal = controller.signal;
async function fetchDataWithCancellation(url) {
try {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`¡Error HTTP! estado: ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch abortado');
} else {
console.error('Error al obtener datos:', error);
}
throw error;
}
}
fetchDataWithCancellation('https://api.example.com/data')
.then(data => console.log('Datos recibidos:', data))
.catch(error => console.error('Fetch fallido:', error));
// Cancelar la operación de fetch después de 5 segundos
setTimeout(() => {
controller.abort();
}, 5000);
Conclusión
Los combinadores de Promesas de JavaScript son herramientas poderosas para construir aplicaciones asíncronas robustas y eficientes. Al comprender los matices de Promise.all, Promise.allSettled, Promise.race y Promise.any, puedes orquestar flujos de trabajo asíncronos complejos, manejar errores con elegancia y optimizar el rendimiento. Al desarrollar aplicaciones globales, es crucial considerar la latencia de la red, los límites de tasa de las API y la fiabilidad de las fuentes de datos. Aplicando los patrones y mejores prácticas discutidos en este artículo, puedes crear aplicaciones de JavaScript que sean tanto de alto rendimiento como resilientes, ofreciendo una experiencia de usuario superior a los usuarios de todo el mundo.